Skip to content

POC: KEDA ScaledJob for queue jobs (payload stays in Redis)#41

Open
abnegate wants to merge 11 commits into
mainfrom
feat/queue-keda-poc
Open

POC: KEDA ScaledJob for queue jobs (payload stays in Redis)#41
abnegate wants to merge 11 commits into
mainfrom
feat/queue-keda-poc

Conversation

@abnegate

@abnegate abnegate commented Jul 1, 2026

Copy link
Copy Markdown
Member

Alternative POC to the env-var KubernetesJob broker (#35), built to compare the two ways of running queue jobs as Kubernetes Jobs.

Approach

The K8s-native pattern: the payload stays in the Redis queue, and KEDA scales worker Jobs off the queue depth.

  • Producers enqueue with the ordinary Utopia\Queue\Broker\Redis broker — no custom broker, no Kubernetes involvement at enqueue time.
  • A KEDA ScaledJob watches the queue's Redis list length and spawns one-shot worker Jobs (up to maxReplicaCount).
  • Each worker (tests/Queue/servers/Keda/worker.php) drains the queue with the same Redis broker (receive → handle → commit) and exits.

Notably this needs zero library code — just the existing Redis broker + a drain worker + the ScaledJob manifest.

Proven end-to-end

tests/keda-e2e.sh stands up kind + KEDA (helm) + Redis + the ScaledJob, loads the worker image, and runs KedaTest, which enqueues messages and asserts KEDA spawns Jobs that drain the queue. Verified on kind (OK (1 test, 4 assertions); observed 8 queue-worker-* Jobs draining the queue). bin/monorepo check queue passes (pint + phpstan + rector).

env-var KubernetesJob (#35) vs KEDA ScaledJob (this PR)

env-var KubernetesJob KEDA ScaledJob
Payload inlined in Job env var — etcd (~1.5 MB)/ARG_MAX limits, visible in pod spec, duplicated per Pod stays in Redis; never on a K8s object
Custom broker yes none — reuses the Redis broker
Producer needs cluster access yes (creates Jobs) no (just Redis)
Scaling one Job per message KEDA scales N Jobs off depth; workers batch-drain
Extra dependency appwrite-labs/php-k8s in the app KEDA operator in the cluster
Secrets/PII in payload exposed via pod spec fine (stays in Redis)

Recommendation: KEDA is the more correct/standard path (keeps the queue a queue, no payload on etcd, no custom broker); its cost is requiring the KEDA operator in the cluster. See tests/Queue/servers/Keda/README.md.

🤖 Generated with Claude Code

Alternative to the env-var KubernetesJob broker: instead of inlining the payload
in a per-message Job, producers enqueue to the ordinary Redis broker and a KEDA
ScaledJob scales one-shot worker Jobs off the queue depth. Each worker drains
the queue with the same Redis broker and exits — no custom broker, no payload on
any Kubernetes object.

Adds a drain worker, Dockerfile, redis + ScaledJob manifests, a kubectl-driven
e2e (KedaTest) proving KEDA spawns Jobs that drain the queue, and keda-e2e.sh
(kind + KEDA + redis). Verified end-to-end on kind. See servers/Keda/README.md
for the comparison.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 1, 2026 04:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a proof-of-concept for running utopia-php/queue jobs as Kubernetes Jobs via KEDA ScaledJob, keeping job payloads in the existing Redis queue (no Kubernetes involvement at enqueue time, no payload stored on K8s objects).

Changes:

  • Introduces a KEDA worker image + one-shot drain worker script and a ScaledJob manifest for scaling off Redis list depth.
  • Adds an end-to-end PHPUnit test (KedaTest) plus a kind + KEDA + Redis harness script to validate the flow.
  • Documents the approach and trade-offs vs the env-var KubernetesJob approach.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/queue/tests/Queue/servers/Keda/worker.php One-shot worker that drains Redis queue messages using the existing Redis broker.
packages/queue/tests/Queue/servers/Keda/README.md Documentation for running the KEDA ScaledJob POC and trade-offs.
packages/queue/tests/Queue/servers/Keda/k8s.yaml Kind-friendly namespace + Redis + KEDA ScaledJob manifest for scaling jobs from Redis depth.
packages/queue/tests/Queue/servers/Keda/Dockerfile Builds the worker container image (PHP + redis extension + project code).
packages/queue/tests/Queue/E2E/Adapter/KedaTest.php E2E test that enqueues directly into Redis and asserts KEDA drains the queue via Jobs.
packages/queue/tests/keda-e2e.sh Harness that provisions kind + KEDA, loads the worker image, applies manifests, and runs the test.
packages/queue/phpunit.xml Registers KedaTest in the e2e testsuite (skips unless KEDA_E2E=true).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/queue/tests/Queue/servers/Keda/worker.php Outdated
Comment thread packages/queue/tests/Queue/servers/Keda/k8s.yaml Outdated
Comment thread packages/queue/tests/keda-e2e.sh Outdated
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a KEDA ScaledJob-based approach for consuming queue jobs as Kubernetes Jobs, where the payload stays in Redis and KEDA scales workers off the queue depth. It adds the KubernetesJob adapter (run-to-completion drain loop), a runsToCompletion() hook in Server, a properly checksummed kind+KEDA e2e harness, and unit tests backed by an InMemoryConnection.

  • KubernetesJob adapter (src/Queue/Adapter/KubernetesJob.php): new run-to-completion adapter that drains the Redis queue and exits, with Swoole and pcntl signal support for graceful shutdown.
  • Server.php change: the outer error catch now re-throws for runsToCompletion() adapters, surfacing infrastructure failures as a non-zero exit code so the Kubernetes Job is marked failed.
  • E2e harness (keda-lib.sh, keda-e2e.sh, e2e.sh): kind + KEDA provisioning with pinned, checksum-verified tool downloads; KEDA setup is required in CI and best-effort locally.

Confidence Score: 4/5

The PR is safe to merge as a POC with one defect in the graceful-shutdown path of the new adapter.

The new KubernetesJob::stop() closes the consumer connection immediately upon receiving SIGTERM, but the in-flight message's commit()/reject() calls still need that connection. Both fail silently inside process(), leaving the message orphaned in the processing list — the opposite of the stated intent. The consumer close is redundant because Server.php's workerStop callback already closes it after the drain loop exits. All other parts of the PR — the drain logic, error handling, e2e harness, checksummed tool installs, and unit tests — look correct.

packages/queue/src/Queue/Adapter/KubernetesJob.php — specifically the stop() method

Important Files Changed

Filename Overview
packages/queue/src/Queue/Adapter/KubernetesJob.php New run-to-completion adapter for Kubernetes Jobs; stop() closes the consumer while process() may still be executing, silently orphaning the in-flight message
packages/queue/src/Queue/Server.php Adds runsToCompletion() re-throw in the outer catch of start() — limited to errors that escape the lifecycle, not per-message errors; looks correct
packages/queue/src/Queue/Adapter.php Adds default runsToCompletion(): false to the base class; minimal and correct
packages/queue/tests/Queue/E2E/Adapter/KubernetesJobAdapterTest.php Unit tests for drain-to-completion, empty-queue, and failed-message-continue behaviors using InMemoryConnection; thorough coverage of the happy path
packages/queue/tests/Queue/E2E/Adapter/KedaTest.php End-to-end test for the KEDA ScaledJob approach; now also asserts failed queue is empty to prevent false positives when all messages are rejected
packages/queue/tests/keda-lib.sh Sourced helpers for kind + KEDA setup; all three tools (kind, kubectl, helm) are downloaded at pinned versions and verified with sha256 checksums
packages/queue/tests/e2e.sh Updated to conditionally provision KEDA in CI (hard-fail on error) and best-effort locally (falls through if Docker is unavailable)
packages/queue/tests/Queue/servers/Keda/k8s.yaml ScaledJob manifest for the POC with Redis deployment, service, and KEDA trigger; crash-recovery limitation is documented in comments and README
packages/queue/tests/Queue/servers/Keda/worker.php Minimal worker entry-point using KubernetesJob adapter with env-var configuration for Redis host/queue; straightforward
packages/queue/tests/keda-e2e.sh Standalone KEDA e2e harness; sets up kind cluster, runs KedaTest, tears down on exit

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/queue/src/Queue/Adapter/KubernetesJob.php:75-81
**`stop()` closes the consumer before in-flight message can commit/reject**

The class docblock states "SIGTERM/SIGINT trigger `stop()` so pod termination **finishes** the in-flight message instead of stranding it in the processing list," but the implementation contradicts this. When SIGTERM fires while `process()` is executing (e.g., between `messageCallback` returning and `commit()` being called), `consumer->close()` runs immediately. `process()` then calls `commit()` on a closed connection, which throws; the inner `catch` falls through to `reject()`, which also throws on the same closed connection; both exceptions are swallowed, and the message is orphaned in the processing list — the very outcome the docblock says this prevents.

The consumer does not need to be closed here; `Server.php` already closes it in the `workerStop` callback (line 335). The `RECEIVE_TIMEOUT = 2` constant means a blocking `receive()` call will unblock within 2 seconds without forcibly closing the connection, which is an acceptable shutdown latency for a Kubernetes Job. Removing the `$this->consumer->close()` call from `stop()` lets the in-flight `process()` complete, then the drain loop exits on the `$this->isStopped()` check, and the consumer is cleaned up by `workerStop`.

Reviews (11): Last reviewed commit: "refactor(queue): fold single-use helpers..." | Re-trigger Greptile

Comment thread packages/queue/tests/Queue/E2E/Adapter/KedaTest.php
Comment thread packages/queue/tests/Queue/servers/Keda/k8s.yaml
Comment thread packages/queue/tests/Queue/E2E/Adapter/KedaTest.php
Factor the kind + KEDA + Redis + ScaledJob provisioning into tests/keda-lib.sh
(keda_up/keda_down) and have both keda-e2e.sh and the package's e2e.sh use it,
so `bin/monorepo test queue` (and thus CI) stands up KEDA and runs KedaTest
against a real cluster instead of skipping it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/queue/tests/keda-lib.sh
- keda-lib.sh: pin + SHA-256-verify Helm (same as kind/kubectl) instead of
  curl|bash of an unpinned installer; only tear down a kind cluster this run
  created, never a pre-existing one.
- KedaTest: also assert the .failed.* list is empty — draining the main queue
  alone doesn't prove success since receive() pops before handling; note that
  enqueue() mirrors Redis::enqueue()'s envelope.
- k8s.yaml: correct the scaling comment (KEDA scales N Jobs, workers batch-drain)
  and document the backoffLimit/processing-orphan crash-recovery caveat.
- README: crash-recovery section (processing-list reaper via Publisher::retry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@abnegate

abnegate commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Addressed in 4781c8f:

greptile-apps

  • P1 — assertion passes even if all messages are rejected (KedaTest): receive() pops from the main list before handling, so a drained main queue alone isn't proof. Now also asserts LLEN utopia-queue.failed.keda == 0 (OK (1 test, 5 assertions) on kind).
  • P1/security — Helm via unpinned curl | bash (keda-lib.sh): Helm is now downloaded at a pinned version (v3.16.4) and SHA-256-verified before use, matching the kind/kubectl pattern.
  • P2 — backoffLimit: 0 can orphan messages (k8s.yaml): documented — a pod killed after receive() claims a message but before commit/reject strands it in the processing list, invisible to KEDA; backoffLimit can't recover it (a new pod only drains the main queue). Production needs a periodic Publisher::retry() reaper. Noted in k8s.yaml and servers/Keda/README.md.
  • P2 — enqueue hard-codes the wire format (KedaTest): added a comment noting it mirrors Redis::enqueue()'s envelope (the in-cluster Redis isn't exposed to the host, so we push via kubectl).

Copilot

  • k8s.yaml comment — corrected to reflect that KEDA scales N Jobs off queue depth and workers batch-drain (not one Job per message).
  • keda-e2e.sh cleanup deleting a pre-existing clusterkeda-lib.sh now tracks whether it created the cluster and only deletes it in that case.
  • worker.php "string interpolation is a parse error" — false positive: "{$message->getPid()}" is valid PHP (curly-brace syntax supports method calls). php -l is clean and the worker runs in the e2e (it drains the queue). Left as-is.

The KEDA approach still needs one bit of library code: a consumer that drains
the queue and exits (so a Job completes) instead of blocking like the Swoole/
Workerman adapters. Extract that out of the test worker into
Utopia\Queue\Adapter\KubernetesJob, cover it with a bare-host unit test
(KubernetesJobAdapterTest), and have the KEDA worker run it via Server. Producers
are unchanged — they still enqueue with any Publisher (e.g. the Redis broker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/queue/tests/e2e.sh
abnegate and others added 2 commits July 1, 2026 22:42
keda_up ran unconditionally under set -e, so a provisioning failure (no
Docker, kind download failure) aborted the whole e2e suite before phpunit,
skipping the Swoole/Workerman/pool tests. Gate it: required in CI (hard fail
so KEDA regressions surface), best-effort locally where KedaTest self-skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d termination and boot failures

Register pcntl SIGTERM/SIGINT handlers in the KubernetesJob consume loop so
graceful pod termination drains the in-flight message instead of being SIGKILLed
(PHP is PID 1 in the Job container, so an unhandled SIGTERM is ignored). Run the
workerStart callbacks under try/finally so workerStop (Timer cleanup) always runs.
Add runsToCompletion() to Adapter, overridden true in KubernetesJob, and rethrow
boot/consume-loop failures from Server::start() for run-to-completion adapters so
the Job fails (honouring backoffLimit) rather than exiting 0. Guard the Redis
broker claim writes after the pop and requeue the payload on failure so a mid-claim
error no longer strands the message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@abnegate
abnegate force-pushed the feat/queue-keda-poc branch from d399b5c to df8e154 Compare July 14, 2026 06:40
abnegate and others added 2 commits July 14, 2026 19:25
php:8.4-cli-alpine ships without pcntl, so the new SIGTERM handling in
Adapter\KubernetesJob threw at startup and every KEDA Job exited
non-zero (the run-to-completion rethrow working as intended) before
draining, failing KedaTest. Suggest ext-pcntl at the package level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pcntl conflicts with Swoole's signalfd, so when Swoole is loaded the
drain runs inside a coroutine scheduler with a sibling coroutine on
Coroutine\System::waitSignal. pcntl remains only as the non-Swoole
fallback. Throwables are captured inside the scheduler and rethrown
after run() since exceptions cannot cross coroutine boundaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/queue/src/Queue/Broker/Redis.php Outdated
abnegate and others added 3 commits July 14, 2026 20:18
Timers and signal watchers registered by workerStart hooks (e.g. a
telemetry Timer::tick) must be created and cleared inside the same
coroutine scheduler, or Coroutine\run never returns and the Job never
completes. Coroutine::cancel on a waitSignal coroutine also leaves an
orphaned signalfd watcher that swallows SIGTERM with no callback, so
signals now use Process::signal registered and removed around the
drain. Proven against the real cloud worker: empty-queue exit 0 in 2s,
SIGTERM mid-drain graceful exit 0, message consumed off the queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A mid-claim Redis failure now propagates as before; the run-to-completion
adapter surfaces it as a failed Job rather than silently requeueing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +75 to +81
public function stop(): self
{
$this->stopped = true;
$this->consumer->close();

return $this;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 stop() closes the consumer before in-flight message can commit/reject

The class docblock states "SIGTERM/SIGINT trigger stop() so pod termination finishes the in-flight message instead of stranding it in the processing list," but the implementation contradicts this. When SIGTERM fires while process() is executing (e.g., between messageCallback returning and commit() being called), consumer->close() runs immediately. process() then calls commit() on a closed connection, which throws; the inner catch falls through to reject(), which also throws on the same closed connection; both exceptions are swallowed, and the message is orphaned in the processing list — the very outcome the docblock says this prevents.

The consumer does not need to be closed here; Server.php already closes it in the workerStop callback (line 335). The RECEIVE_TIMEOUT = 2 constant means a blocking receive() call will unblock within 2 seconds without forcibly closing the connection, which is an acceptable shutdown latency for a Kubernetes Job. Removing the $this->consumer->close() call from stop() lets the in-flight process() complete, then the drain loop exits on the $this->isStopped() check, and the consumer is cleaned up by workerStop.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/queue/src/Queue/Adapter/KubernetesJob.php
Line: 75-81

Comment:
**`stop()` closes the consumer before in-flight message can commit/reject**

The class docblock states "SIGTERM/SIGINT trigger `stop()` so pod termination **finishes** the in-flight message instead of stranding it in the processing list," but the implementation contradicts this. When SIGTERM fires while `process()` is executing (e.g., between `messageCallback` returning and `commit()` being called), `consumer->close()` runs immediately. `process()` then calls `commit()` on a closed connection, which throws; the inner `catch` falls through to `reject()`, which also throws on the same closed connection; both exceptions are swallowed, and the message is orphaned in the processing list — the very outcome the docblock says this prevents.

The consumer does not need to be closed here; `Server.php` already closes it in the `workerStop` callback (line 335). The `RECEIVE_TIMEOUT = 2` constant means a blocking `receive()` call will unblock within 2 seconds without forcibly closing the connection, which is an acceptable shutdown latency for a Kubernetes Job. Removing the `$this->consumer->close()` call from `stop()` lets the in-flight `process()` complete, then the drain loop exits on the `$this->isStopped()` check, and the consumer is cleaned up by `workerStop`.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

@@ -342,6 +342,10 @@ function (?Message $message, Throwable $th): void {
foreach ($this->errorHooks as $hook) {
$hook->getAction()(...$this->getArguments($this->resources(), $hook));
}

if ($this->adapter->runsToCompletion()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to hide this/ from the adapter interface?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants